fix(streaming): stop uncaught error when tearing down a connecting socket - #173
Conversation
…cket When a socket still in the CONNECTING state is closed, ws aborts the handshake and emits `error` on the next tick. The teardown paths in discardPendingSocket() and close() call removeAllListeners() first, so that deferred emit found zero listeners and Node escalated it to an uncaughtException that killed the process — outside any caller's try/catch, and before the connect() rejection could even reach the caller. Keep a no-op error sink attached after removeAllListeners(); the failure itself is still reported through the rejected connect() promise. Applies to StreamingTranscriber and the deprecated RealtimeTranscriber alike. Fixes #170 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repro verificationVerified this end-to-end with a standalone repro script (Node v26.4.0) covering both triggers from #170: the Before (published Note After (this branch's built The timeout rejection now reaches the caller's The unit suite couldn't have caught this: the test |
bgotthold-aai
left a comment
There was a problem hiding this comment.
does the version need to get bumped?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #170
Problem
Closing a WebSocket that is still in the
CONNECTINGstate makeswsabort the handshake and emiterroron the next tick (abortHandshake→process.nextTick(emitErrorAndClose, …)). The SDK's teardown paths callsocket.removeAllListeners()immediately beforesocket.close(), so that deferred emit finds zeroerrorlisteners — and Node's EventEmitter escalates it to anuncaughtExceptionthat kills the process. It lands outside the teardown's owntry/catchand outside any caller'stry/catcharoundawait transcriber.connect().Two triggers, both reproduced against
assemblyai@4.36.4/ws@8.21.1:discardPendingSocket(): the timeout fires precisely because the socket is stillCONNECTING. Worse than a second unhandleable error:failAttempt()discards the socket before rejecting, and thenextTickqueue drains ahead of the promise microtask queue — so the process dies before the caller'scatchever runs, and the timeout rejection is never even observable.close()racingconnect():StreamingTranscriber.close()(and the deprecatedRealtimeTranscriber.close()) run the identicalremoveAllListeners()+close()sequence with no guard at all.Fix
After
removeAllListeners(), re-attach a no-op error sink (socket.onerror = () => {}) before callingclose(), in all three teardown sites. Onws, assigningonerrorregisters a real listener, so the deferred emit lands on the sink instead of crashing the process; the failure itself is still reported through the rejectedconnect()promise. Browser builds are unaffected — the sink assignment is harmless, and a browserclose()on aCONNECTINGsocket is a no-op anyway.Tests
New
tests/unit/streaming-connecting-teardown.test.tscovers all three paths and is red without the fix, green with it. The existing unit-testwsmock could never catch this class of bug — itsremoveAllListeners()replaces handlers with no-ops instead of removing them — so the tests inject an EventEmitter-backed fake with realwssemantics (erroremit with zero listeners throws;onerrorassignment registers a listener).Also verified end-to-end: the issue's repro (1ms
connectTimeout, andclose()immediately after an un-awaitedconnect()) crashes the process with the reported stack on the published package, and survives cleanly (exit 0, timeout rejection now reaching the caller'scatch) against this branch's build.🤖 Generated with Claude Code